Skip to content

FE-1604: Allow Voice interruption by speaking - #9619

Merged
kostandinang merged 26 commits into
mainfrom
kostandin/fe-1604-recut-voice-interruption
Sep 10, 2026
Merged

FE-1604: Allow Voice interruption by speaking#9619
kostandinang merged 26 commits into
mainfrom
kostandin/fe-1604-recut-voice-interruption

Conversation

@kostandinang

Copy link
Copy Markdown
Contributor

🌟 What is the purpose of this PR?

This PR lets a person interrupt Voice assistant playback by speaking without losing the interrupting utterance. It keeps Your turn as an optional half-duplex fallback and rejects likely prompt-regurgitation or assistant-echo transcripts before they become answers.

This is an independent semantic recut of the interruption-only delta from the stale, conflicting #9550, based on post-deployment main at ef0f444987. It does not merge or carry the old branch's unrelated Brunch stack. The automated proof establishes protocol ordering, admission, retention, UI preference, and package integrity; it does not establish real microphone latency or acoustic echo behavior.

🔗 Related links

🚫 Blocked by

🔍 What does this change?

  • Keeps microphone capture active during canonical playback when Interruption by speaking is enabled.
  • Preserves immediate speech_started → response.cancel → output_audio_buffer.clear; interruption never clears the input buffer or waits for transcript validation.
  • Carries the same input item through completed transcription and submits it exactly once through the existing Brunch admission path.
  • Retains an accepted interruption while the previous Brunch turn settles, including across follow-on canonical speech and later rejected or failed transcripts.
  • Rejects completed interruption transcripts with strong ordered overlap against the configured transcription vocabulary or the exact canonical text active when interruption began.
  • Uses medium Semantic VAD eagerness while keeping response creation and interruption under Petrinaut control.
  • Adds a browser-saved, default-on Interruption by speaking checkbox to the playback menu and shows Your turn only when it is disabled.
  • Preserves mute, pause, Stop, end, reconnect, exact question replay, and exact full-response replay.
  • Updates the website README, Petrinaut Voice guide, Voice architecture decision record, and Petrinaut patch changeset.
🏗️ Agent notes

Recut provenance

The stale PR head f69ac17034 contained four interruption product commits after its FE-1580 parent. Those commits were applied onto current main; range comparison found one cancellation-settlement hunk already present on main, with the remaining semantic patches unchanged. The current NodeNext website boundary additionally required the new shared transcription import to use its .js extension.

Constraints retained

  • Completed provider transcription remains the only Voice-answer authority.
  • Cancellation is immediate, output-only, and never gated by classification.
  • Only playback-overlapping input is classified; ordinary capture is unchanged.
  • Self-echo compares only with canonical text active when speech began, not queued speech or history.
  • Rejection diagnostics contain only operational metadata and a reason.
  • Short novel answers such as “stop”, “no”, and “wait” remain admissible.
  • FE-1580: Port omitted Voice settlement behavior #9588 remains an independent sibling and is not included in this branch.

Fog-line and stop conditions

Browser echo cancellation may still permit a false VAD event to stop playback before the completed transcript is rejected. Stop or reorient if current-main behavior clears the interrupting input, delays cancellation, admits an interruption twice, erases an earlier retained answer, or fails to restore acknowledged half-duplex handoff when the preference is disabled.

The complete live mission contract is in libs/@hashintel/brunch-agent/MISSION.md.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

  • Modifies an npm-publishable library and includes a patch changeset.

📜 Does this require a change to the docs?

  • Requires docs changes, made in apps/petrinaut-website/README.md, libs/@hashintel/petrinaut/docs/ai-assistant.md, and libs/@hashintel/brunch-agent/docs/adr/0009-openai-voice-ui-turn-shell.md.

🕸️ Does this require a change to the Turbo Graph?

  • Does not affect the execution graph.

⚠️ Known issues

  • A false VAD event can still stop playback even when the completed transcript is discarded.
  • The deterministic classifier is conservative and heuristic; real speaker/microphone behavior still needs a human witness.
  • Protocol tests do not benchmark audible interruption latency.
  • Website ESLint reports one pre-existing set-state-in-effect warning in the Voice control component; there are zero errors.

🛡 What tests cover this?

Coverage includes immediate cancellation and input preservation; duplicate completion; delayed and pending admission; follow-on canonical speech; retained-answer display; prompt-regurgitation and active-playback self-echo rejection; short novel answers; unchanged ordinary capture; preference/UI behavior; and lifecycle cleanup.

Fresh verification on this recut:

  • Focused Voice suites: 13 files, 246 tests passed.
  • Full @apps/petrinaut-website suite: 41 files, 417 tests passed.
  • Full @hashintel/petrinaut suite: 94 files, 800 tests passed.
  • Dependency-aware TypeScript checks, ESLint, and builds passed for both affected workspaces (19/19 Turbo tasks).
  • Petrinaut architecture lint passed: 78 layers, 409 edges, 786 files.
  • Repository formatting and git diff --check passed.

❓ How to test this?

  1. Start Voice and open Voice playback options. Confirm Interruption by speaking is checked by default.
  2. While Brunch is speaking, begin answering, including a short answer such as “stop”, “no”, or “wait”. Confirm playback stops immediately and the same utterance appears once, including if follow-on canonical speech is queued before transcription completes.
  3. Interrupt while the previous Brunch turn is still settling. Confirm Answer captured. Waiting for Brunch. remains visible and submits once input reopens. Confirm a later rejected echo does not erase that notice.
  4. Uncheck Interruption by speaking. Confirm Your turn returns and the microphone stays closed during playback until the acknowledged handoff completes. Reload and confirm the preference is remembered.
  5. Exercise mute, pause, Stop, end, reconnect, Repeat question, and Read full response in both modes.

📹 Demo

The original interruption-only capture remains representative of this semantic recut:

fe-1604-voice-interruption-by-speaking.mp4

kostandinang and others added 6 commits September 9, 2026 11:19
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hash Ready Ready Preview Sep 10, 2026 2:48pm UTC
hashdotdesign-tokens Ready Ready Preview Sep 10, 2026 2:48pm UTC
petrinaut Ready Ready Preview Sep 10, 2026 2:48pm UTC
petrinaut-docs Ready Ready Preview Sep 10, 2026 2:48pm UTC

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team area/apps labels Sep 9, 2026
@kostandinang
kostandinang marked this pull request as ready for review September 9, 2026 09:25
@kostandinang kostandinang self-assigned this Sep 9, 2026
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes real-time voice turn-taking, transcript admission, and Brunch submission ordering; misclassification or race bugs could drop or duplicate answers, though behavior is heavily tested and defaults are conservative.

Overview
Voice can now interrupt assistant playback by speaking, with Interruption by speaking on by default and stored in the browser. While enabled, the mic stays open during canonical speech; speech_started immediately cancels generation and clears output audio without clearing the input buffer. Completed transcripts still go through Brunch once, can be retained until the prior turn settles, and are classified (prompt-regurgitation / self-echo) only for interruption-originated completions against active playback text.

Disabling the preference restores half-duplex behavior: Your turn handoff, mic closed during output, and stricter overlap rules for in-flight input. The Realtime session tracks speaking/stopped input items, defers canonical playback until speech ends, and settles cancelled speech requests more explicitly. The bridge adds ordered input draining, pending-interruption slots, and content-free transcription rejection diagnostics.

UI exposes the preference via voice controls (persisted with readInterruptionBySpeakingPreference / saveInterruptionBySpeakingPreference), new dock notices for pending answers, and Semantic VAD medium eagerness. Docs and a @hashintel/petrinaut patch changeset accompany broad integration/unit test coverage.

Reviewed by Cursor Bugbot for commit 4e38d31. Bugbot is set up for automated code reviews on this repo. Configure here.

@lunelson

lunelson commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

I found four behavioral blockers and one mission-contract gap:

  1. Short exact self-echo is admitted. classify-interruption.ts returns null for every transcript under six tokens before comparing canonical playback. An exact echo such as “Who approves this?” therefore becomes an answer. The contract permits short novel answers, not short exact echoes.

  2. Generated audio awaiting playback escapes spoken interruption. #interruptOutputBySpeaking() cancels only active or already-speaking responses. After response.done but before output_audio_buffer.started, speech does not cancel/clear the terminal buffered response. The manual-cancellation suite models this phase, but the spoken-interruption “generated” case starts playback first.

  3. Pause or host Stop silently drops a retained answer. VoiceTurnController.pause() / host Stop call cancelPendingSpeech(), which sets RealtimeBrunchBridge.#pendingInterruption = null. The controller can continue showing “Answer captured. Waiting for Brunch” after the answer has become undeliverable.

  4. Cancellation before response.created can leave permanent output ownership. The cancelled-created path returns before recording speechRequestId; its terminal event cannot clear #pendingSpeechRequestIds in the bridge. Later ordinary speech can remain classified against phantom pending output. The bridge test fabricates a terminal speechRequestId that the real session does not emit in this path.

Separately, MISSION.next.md requires every later Voice mission to exercise the typed-origin + Voice-origin + durably-stopped second-tab reopen scenario. FE-1604 exercises Voice but does not include that gate. This is also still deferred in FE-1573 Step B, so the ownership/disposition should be explicit rather than silently left there.

The architecture and overall branch scope otherwise align well: provider logic remains website-owned, Petrinaut stays provider-neutral, and admission still uses the existing Brunch path.

kostandinang and others added 4 commits September 9, 2026 14:39
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

kostandinang commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Addressed and pushed in 268303c8a4, with the ownership follow-up in a16c19ef0f (mission authority changes remain committed separately):

  1. Exact normalized playback echoes are now rejected before the six-token fuzzy-overlap threshold. The regression also proves a short novel answer is still admitted unchanged.
  2. Spoken interruption clears completed canonical audio buffered between response.done and output_audio_buffer.started; response.cancel is sent only while the response is active. The buffer-clear acknowledgement now carries speechRequestId downstream, so both the bridge and controller release generated-but-unplayed output ownership and restore capture.
  3. Output cancellation no longer deletes a finalized retained interruption. Delivery waits for both cancellation handoff and chat readiness. Both Stop/cancellation race orders now assert that the first gate alone cannot drain the retained answer.
  4. Response-to-speech correlation is recorded as soon as canonical response.created arrives and retained independently through output cleanup, so a pre-created cancellation's terminal event carries speechRequestId and releases bridge ownership.
  5. MISSION.md owns the combined continuity gate. voice-history-continuity.integration.test.tsx exercises typed history, supported Voice client-tool provenance, durable composer Stop, close/reopen through a second mounted Flue observation, stopped rendering, and local End Voice without another durable Stop. Per the owner decision, direct-user Voice source reconstruction is explicitly deferred: Flue 2.0.3 exposes no supported caller-metadata/idempotency-key projection, and the prohibited browser-side/text-encoding alternatives were not introduced.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lunelson

Copy link
Copy Markdown
Contributor

I still see four code-level gaps:

  1. High — rejected response creation still leaks output ownership. If spoken interruption marks a pending response.create cancelled and the provider returns correlated conversation_already_has_active_response, openai-realtime-session.ts cancels its local timing and returns without emitting response-terminal or output-interrupted. The bridge and controller therefore retain the canonical-speech-requested ID indefinitely, leaving phantom output ownership until another manual cancellation or reconnect.

  2. Medium — Stop can discard an already-completed answer. With start/stop A → start/stop B → completed(B), B waits in #completedInputEvents for A. RealtimeBrunchBridge.cancelPendingSpeech() then calls #retirePendingInputItems(), which rejects and clears B as unavailable even though its transcription is complete. This reintroduces the retained-answer loss when completion is buffered behind an earlier item rather than already moved into #pendingInterruption.

  3. Medium — half-duplex playback can still cut off accepted follow-up speech. After start/stop A → start B → completed(A), the controller preserves B and leaves the microphone enabled. If A's canonical response reaches speakCanonical() before B stops, #sendNextCanonicalSpeech() waits for #speakingInputItemId only when interruption mode is enabled; in half-duplex mode it starts playback and marks unfinished B as overlapping, so B's eventual transcript is discarded. Existing regressions stop or complete B before A's response arrives.

  4. Medium — pending playback is still classified as an interruption. canonical-speech-requested makes #ownsOutputTurn() true before any output-started event. Speech beginning in that gap records an empty playback snapshot and still runs classifyInterruption(), allowing prompt-regurgitation rejection even though no canonical audio was active. The branch contract says interruption-only classification applies only to input begun during active playback, and the current test explicitly preserves the contrary pending-creation behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lunelson

Copy link
Copy Markdown
Contributor

One high-severity lifecycle gap remains: cancelled response.create collision handling is incomplete across inverse event orders.

  • Collision before interruption: a canonical request emits canonical-speech-requested, receives correlated conversation_already_has_active_response, and is requeued. If the user then speaks before the competing response terminates, #interruptOutputBySpeaking() removes the requeued request and cancels its local timing without emitting response-terminal or output-interrupted. The bridge/controller retain phantom ownership for the original speech request indefinitely.
  • Competing terminal before the cancelled request's error: request A is pending and cancelled by speech, request B queues, then the competing response emits response.done before A's correlated collision error. The terminal's resume attempt is blocked by A's still-set #responseCreateEventId; A's error later clears that ID and returns from the cancelled branch without calling #resumeCanonicalSpeechQueue(), so B remains queued permanently even though its segments have already been marked seen upstream.

The new cancelled-creation regression covers speech before the collision error, but not either collision-first cleanup or a queued successor with response.done preceding the correlated error.

kostandinang and others added 2 commits September 10, 2026 16:12

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 350f717. Configure here.

@lunelson

Copy link
Copy Markdown
Contributor

One medium-severity lifecycle gap remains: a retained interruption can remain permanently pending after an empty completed response.

In realtime-brunch-bridge.ts, when the active correlated submission reaches ready with a matching completed settlement but no responseSegments, the branch at lines 959–972 clears #activeSubmission, emits canonical-response-ready, and returns without calling #drainPendingInterruption(). If the user’s interrupting answer was retained while that submission was active, no further chat update or playback handoff is guaranteed to drain it. The dock can remain on “Answer captured. Waiting for Brunch.” indefinitely, and later speech is rejected as pending.

The existing empty-response regression submits the next transcript only after settlement. It does not cover retaining the next transcript before the empty settlement completes.

@kostandinang

Copy link
Copy Markdown
Contributor Author

One medium-severity lifecycle gap remains: a retained interruption can remain permanently pending after an empty completed response.

In realtime-brunch-bridge.ts, when the active correlated submission reaches ready with a matching completed settlement but no responseSegments, the branch at lines 959–972 clears #activeSubmission, emits canonical-response-ready, and returns without calling #drainPendingInterruption(). If the user’s interrupting answer was retained while that submission was active, no further chat update or playback handoff is guaranteed to drain it. The dock can remain on “Answer captured. Waiting for Brunch.” indefinitely, and later speech is rejected as pending.

The existing empty-response regression submits the next transcript only after settlement. It does not cover retaining the next transcript before the empty settlement completes.

This issue is already fixed in the latest PR version. The empty-response path now drains retained interruptions, with a regression test covering the exact scenario. Bugbot and tests pass ✅

@lunelson lunelson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retained-interruption gap is fixed and covered by the exact empty-response regression. The previously reported collision races are also resolved; focused Voice lifecycle tests pass.

@kostandinang
kostandinang added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 8313470 Sep 10, 2026
64 checks passed
@kostandinang
kostandinang deleted the kostandin/fe-1604-recut-voice-interruption branch September 10, 2026 15:37
@hash-release hash-release Bot mentioned this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash.design Affects the `hash.design` design site (app) area/apps area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants